Skip to content

refactor(buzz-agent): swap the agent loop onto the goose library - #3262

Draft
michaelneale wants to merge 83 commits into
mainfrom
micn/buzz-agent-goose-core
Draft

refactor(buzz-agent): swap the agent loop onto the goose library#3262
michaelneale wants to merge 83 commits into
mainfrom
micn/buzz-agent-goose-core

Conversation

@michaelneale

@michaelneale michaelneale commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

What changed

Previously, Buzz implemented the agent stack itself:

loop {
    let response = buzz_provider.complete(&history, &tools).await?;
    history.push(response);

    if response.tool_calls.is_empty() {
        return stop_or_continue_via_buzz_hooks();
    }

    history.push(buzz_mcp.execute(response.tool_calls).await);
}

Buzz owned provider adapters, messages, MCP, tool execution, compaction, and loop policy.

Now, Buzz owns orchestration and Buzz-specific policy while composing Goose GDK primitives:

loop {
    apply(buzz_round_start.step(&conversation).await?); // steer, compact

    match goose_provider.stream(&conversation, &goose_tools).await {
        Ok(response) if response.has_tool_calls() =>
            conversation.push(buzz_rmcp.dispatch_bounded(response).await),
        Ok(response) =>
            return buzz_end_turn_policy(response).await,
        Err(ContextOverflow) =>
            apply(buzz_compaction.force(&conversation).await?),
    }
}

Buzz-specific policy is expressed as small Goose operations:

round_start = [Steer, BuzzCompaction];
round_gate  = [MaxRounds, StopVeto, ReplyGuard];

Buzz still owns when to compact, overflow recovery and retry, conversation replacement, and todo restoration. Goose supplies the context-management primitives:

if buzz_policy.should_compact(usage, context_limit) || provider_overflowed {
    let summary = goose_context::summarize(visible_messages).await?;
    conversation = buzz_replace_with_summary(summary);
    restore_buzz_todos().await;
}

In one sentence: previously Buzz built an agent stack; now it composes Goose primitives while retaining the behavior that makes an agent a good Buzz participant.

This is intended to preserve Buzz agent behavior while removing duplicated infrastructure and making future improvements easier.

Skills use Goose’s discovery and rendering APIs but remain selected by Buzz: Buzz can choose filesystem roots or source entries per agent, advertise only a small index, and load the chosen skill body on demand.

Presentation

BUZZ_GDK_BRIEF.pdf

Buzz GDK overview

size impact:

image

…brary

Feasibility spike: keep buzz-agent's ACP wire contract exactly as-is, but
replace the hand-written agent loop with goose used as a Rust library.

13,259 -> 1,562 src LOC (88% cut), plus ~5,800 test LOC that covered the
deleted loop.

Deleted, now goose's:
  llm.rs 3846      -> goose::providers (superset of the 4 providers)
  mcp.rs 1139      -> goose::agents::extension_manager
  auth.rs 845      -> goose::providers (incl. Databricks OAuth)
  hints.rs 726     -> prompt_manager::with_hints
  catalog.rs 631   -> goose::providers::init
  builtin.rs 575   -> goose skills platform extension
  handoff.rs 430   -> goose::context_mgmt (auto-compaction)

Kept deliberately (the contract buzz-acp depends on):
  wire.rs 293 verbatim; agentInfo.name = "buzz-agent" (kind-44200 harness
  attribution); 6-method surface; activeRunId with _meta nested inside
  update; usage_update before the session/prompt response; keepalive
  ticker; error -> JSON-RPC code mapping; single-flight; size caps.

This is NOT "goose as the harness". Picking Goose from the harness gallery
still shells out to a user-installed goose CLI. This is buzz-agent's own
identity with a goose-powered loop.

Why a separate crate excluded from the workspace: crates/buzz-agent is a
library linked into sprig AND desktop/src-tauri, so goose's ~700-crate graph
would land in the Tauri build and the workspace lockfile. Own Cargo.lock,
same isolation trick as PR #1526.

Notable: driving the library is what makes the persona work at all. Goose's
own ACP server never reads systemPrompt (zero hits in goose/crates/goose/src)
and both PRs that would have wired it -- buzz#1290, goose#9971 -- are closed
unmerged. tests/stdio_turn.rs asserts Fizz's prompt reaches the provider.

Also: GOOSE_MODE is left at goose's default rather than forced to "auto"
(auto-approve every tool call), which is what the desktop catalog ships for
the external goose runtime.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Makes the spike reproducible off this machine and closes the model-switch
stub.

- Depend on aaif-goose/goose @ 305849b71 (v1.44.0, ancestor of origin/main)
  instead of a local path. Verified: cargo fetch + full test run from a clean
  git checkout of the dep.
- session/set_model now takes effect. The id is staged in `pending_model` and
  consumed by the next session/prompt, matching buzz-agent's "applies from the
  next prompt" contract (lib.rs:494-502) so an in-flight turn is never mutated.
  Rebuilds the provider and hot-swaps it via update_provider; SharedProvider is
  an Arc<Mutex<Option<..>>> for exactly this.
- New stdio test covers unknown-session, empty-modelId, and a real switch
  followed by a completing turn.

15 tests green. Measured release binary (macOS arm64):

  new  32.5 MiB raw / 9.9 MiB gzip -9
  old   9.8 MiB raw / 3.9 MiB gzip -9
  delta +22.7 MiB raw / +6.0 MiB gzip

Corroborates PR #1526's +22.9 MiB raw / +6.2 MiB gzip.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
session/new returned only `sessionId`, so the desktop ModelPicker degraded to
"current model only" and buzz-acp could not resolve session/set_model targets
(resolve_model_switch_method, buzz-acp/src/acp.rs:1876).

This was a regression I introduced by deleting buzz-agent's catalog.rs without
replacing what it fed -- not a limitation of driving goose as a library. The
picker is the same UI and the same buzz-acp code path for every agent; goose's
CLI fills it via build_model_state, buzz-agent filled it via Databricks
discovery, and this crate filled it with nothing.

Cannot reuse goose's builder: build_model_state is pub(super)
(acp/response_builder.rs:130), invisible outside goose::acp. The underlying
data is public, so discover_models() rebuilds the same shape from
Provider::fetch_supported_models (goose-provider-types/base.rs:425) via
Agent::provider(), including goose's rule that the current model is prepended
when the provider's list omits it.

Absent catalog stays degraded UX, never a session failure -- matching
buzz-agent's Databricks fallback (catalog.rs:52-80).

New stdio test asserts the shape buzz-acp actually parses: currentModelId plus
availableModels entries keyed by `modelId`. 16 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…njection

Closes the last behavioural gap. buzz-agent's most load-bearing non-standard
behaviour -- the agent may not end its turn while its todo list has open items
-- now works, with no changes to goose.

Why not goose's hook system. Goose has a blocking Stop hook with exactly these
semantics (agent.rs:2891-2917), but it is unreachable: hook_manager is private
with a #[cfg(test)] setter, and hooks are otherwise discovered as subprocesses
from <root>/.goose/plugins/*/hooks/hooks.json. The subprocess route looked
viable until you notice where the answer lives -- buzz-dev-mcp's todo list is
in-process state (todo.rs:49, a Mutex<Vec<Item>>) and that process's stdio is
owned by goose. A hook binary goose spawns cannot see it, so a materialised
hooks.json would produce a hook that always answers "no objection": worse than
no hook, because it looks like it works.

Instead we own the outer loop, so we ask the tool ourselves.
Agent::dispatch_tool_call is public (agent.rs:1059). Between rounds we call
_Stop on the same extension the model uses; on objection we re-enter reply()
with the objection as an agent-visible/user-invisible message. Capped at 3
consecutive vetoes, mirroring goose's own stop_hook_block_cap.

_PostCompact is wired to AgentEvent::HistoryReplaced and re-injects via steer(),
which goose drains at the round boundary (agent.rs:1951-1974).

Extension name is discovered by "___Stop" suffix rather than hardcoded --
buzz-acp derives it from the MCP binary's file stem (buzz-acp/src/lib.rs:4145),
so it is not a fixed string.

KNOWN DEVIATION: buzz-agent hid _-prefixed tools from the model
(agent.rs:328-336) while still calling them itself. Goose's available_tools
allowlist gates advertising and dispatch through the same cache
(extension_manager.rs:1421, :1698), so hiding them would make them
undispatchable and break the veto. They stay visible; a system-prompt
extension tells the model not to call them.

4 new tests against the real fake-mcp binary (copied from crates/buzz-agent)
counting provider generations: 2 objections => 3 calls, permanent objection
capped at 4, no hook => 1 call, and discovery under a non-obvious extension
name. 21 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Last known behavioural gap. buzz-agent appended a reflection nudge to every
failed tool result (agent.rs:21-22, :364) so the model diagnoses the failure
instead of blindly retrying.

Goose gives no interception point for that: PostToolUseFailure is
fire-and-forget and its output is discarded (agent.rs:589-620). So we deliver
the same text via steer(), which goose drains at the round boundary
(agent.rs:1951-1974) -- exactly when the model would next act on the failed
result. Agent-visible, user-invisible. Capped at 8 per turn so a tool failing
in a loop cannot flood the conversation.

Tested against the real provider wire rather than trusting that steer() was
called: the fake provider records every chat-completions body, and the test
asserts [Reflect] is absent from the first generation and present in a later
one. Negative test confirms a successful tool call injects nothing.

Adds FAKE_MCP_TOOL_ERROR to the fake MCP server. 23 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Every other test in this crate drives `fake-mcp`, which answers whatever the
test tells it to. That proves our plumbing, not that it matches the server buzz
actually ships. These two drive the real binary: real tool names, real schemas,
real in-process todo state, real _Stop/_PostCompact semantics.

real_dev_mcp_stop_hook_blocks_end_of_turn scripts the exact scenario the veto
exists for -- the model records an open todo item via the real `todo` tool, then
tries to stop. Asserts the turn is extended and that buzz-dev-mcp's own
objection text ("open todo items") reaches the model.

real_dev_mcp_advertises_its_tools pins the shipped tool surface (shell,
read_file, str_replace, todo) and locks in the KNOWN DEVIATION: _Stop stays
visible to the model, with system-prompt guidance not to call it.

Both skip cleanly if buzz-dev-mcp isn't built.

25 tests green, fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
The new cancel test caught two real bugs, both from breaking out of the select
loop the moment the token fired.

Dropping `stream` drops the futures goose is awaiting, so
`mcp_client.rs:688` never reaches its `cancel_token.cancelled()` arm and never
sends `notifications/cancelled`. Consequences: the MCP child keeps running its
tool after the turn is over, and any announced `tool_call` never reaches a
terminal state -- the desktop renders that as a spinner forever, which is the
invariant buzz-agent held at agent.rs:470-477.

Cancellation is cooperative, so treat it that way: keep polling the stream and
let goose unwind (emit tool responses, send the MCP cancellations, end the
stream), bounded by CANCEL_DRAIN_TIMEOUT = 5s. Track announced-minus-resolved
tool call ids and synthesise terminal updates for any stragglers if the drain
times out -- a wrong status beats a stuck spinner.

3 new tests: cancel mid-tool-call returns stopReason=cancelled with every tool
call resolved and activeRunId cleared; notifications/cancelled actually reaches
the MCP server (asserted via FAKE_MCP_CANCEL_LOG); cancel for an unknown
session doesn't kill the process.

28 tests green, fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Steering injects a message into a live turn without cancelling it. buzz-acp
prefers it over cancel+re-prompt because the latter throws away the model's
in-progress work, and it guards the call with optimistic concurrency.

Four tests, all against the real stdio wire:

- steer_injects_without_cancelling_the_turn: asserts the turn still ends with
  end_turn AND that the steered text reached the provider. Goose's steer() is
  drained at the round boundary (agent.rs:1951-1974), same as buzz-agent's.
- steer_with_stale_run_id_is_rejected / steer_outside_a_turn_is_rejected: both
  must be errors so buzz-acp can fall back to cancel+merge
  (buzz-acp/src/pool.rs:329-366) rather than silently steering the wrong turn.
- active_run_id_is_cleared_when_the_turn_ends: asserts an explicit trailing
  null, then that a later steer is rejected.

await_active_run_id() reads params.update._meta.goose.activeRunId at exactly
the depth buzz-acp parses (acp.rs:1607-1613) -- a _meta one level too high
silently degrades steering to cancel+re-prompt forever, with no error anywhere.

All 6 ACP methods now have end-to-end coverage. 32 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
My own comment said GooseMode::default() was "deliberately not Auto" and
therefore did not auto-approve tool calls. That is wrong: GooseMode derives
Default with #[default] on Auto (goose_mode.rs:23-25), i.e. every tool call is
approved without asking. The comment documented a security posture the code did
not have -- the most dangerous kind of wrong comment.

Behaviour is unchanged and deliberately so: auto-approve is what buzz ships
today (buzz-acp/src/acp.rs:1671-1712 auto-approves every permission request,
and the desktop catalog sets GOOSE_MODE=auto for the external goose runtime,
discovery.rs:89). Flipping it here would silently change how every existing
agent behaves.

What changes is that it is now a knob instead of a hardcode.
BUZZ_AGENT_APPROVAL selects approve / smart_approve / chat / auto, threaded
through AgentConfig and create_session. Unknown values warn and fall back to
auto -- a typo must not take an agent off the air, and must not silently
tighten either.

Nothing in buzz drives this yet. Wiring it to a real human affordance is the
first step of the isolation work, and this is the seam that work will use.

4 new unit tests pin the mapping and the fallback. 35 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-acp derives harness identity from the command's BASENAME
(normalize_agent_command_identity, buzz-acp/src/config.rs:600-615) and already
has a "buzz-agent" arm meaning "no extra args" (default_agent_args, :617-624).

Naming the binary `buzz-agent` makes this a drop-in swap: point
BUZZ_ACP_AGENT_COMMAND at the built path and buzz-acp cannot tell the
difference -- same identity, same args, same ACP contract, goose underneath.
That is the whole premise, so the artifact should reflect it.

Crate stays buzz-agent-core (it is workspace-excluded and owns its lockfile);
only the emitted binary is renamed. 35 tests green.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Hand-testing this is a swap, not a new setup: `just agent-core` is `just goose`
with BUZZ_ACP_AGENT_COMMAND repointed at the built binary (and MCP_COMMAND at
buzz-dev-mcp). Because buzz-acp identifies a harness by command BASENAME and
the binary is emitted as `buzz-agent`, buzz-acp cannot tell it apart from the
old one -- so the old `just goose` still works for A/B against the same relay.

HANDTEST.md lists the seven things only a human can check, ordered by risk:
persona arrival, the _Stop veto, streaming feel (the most likely source of
"something feels off" -- goose streams token-by-token where buzz-agent emitted
one chunk per round), cancel-mid-tool leaving no stuck spinner, steering not
restarting the turn, the model picker, and whether the model calls the now-
visible _ tools it has been told to leave alone.

Also records what is NOT done: never run against a real provider (Databricks
OAuth is entirely goose's code path now and completely unexercised), never run
inside the desktop app, nothing wired into packaging or the catalog.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-agent keeps its name, its binary, its ACP contract and its place in the
workspace. Only the guts change: ~11k lines of hand-written agent loop are
replaced by the goose crate used as a Rust library.

Deleted, now goose's: llm.rs 3846 (providers), mcp.rs 1139 (extension manager),
hints.rs 726, builtin.rs 575 (skills), handoff.rs 430 (context_mgmt), plus most
of config.rs 2709. Kept: wire.rs verbatim, and the parts goose does not know
about -- the exact session/update shapes buzz-acp parses, the keepalive ticker,
usage_update ordering, activeRunId, and the error taxonomy.

Behaviour preserved, each with an end-to-end stdio test: the Fizz persona (the
reason for embedding -- goose's own ACP server never reads systemPrompt, so
this only works via the library API), the _Stop end-turn veto, _PostCompact
re-injection, [Reflect] on failed tool calls, the model catalog, set_model,
cancel and steer. Validated against the real buzz-dev-mcp, not just a fake.

Two dependency conflicts had to be solved to get goose into the workspace:

1. goose pins icu_locale "=2.1.1" (needs icu_collections ~2.1.1) while
   url 2.5.x -> idna -> idna_adapter 1.2.2 pulls icu_normalizer 2.2.0 (needs
   icu_collections ~2.2.0). Only one 2.x icu_collections can be selected.
   Fixed by pinning idna_adapter "=1.2.0", the last release on ICU4X 1.x,
   which keeps IDNA off that line entirely.

2. The desktop could no longer link buzz-agent at all: goose pulls
   sqlx-sqlite -> libsqlite3-sys 0.30, desktop has rusqlite 0.37 ->
   libsqlite3-sys 0.35, and both declare links = "sqlite3". Cargo forbids
   that and no pin resolves it. But the desktop only ever used Databricks
   model discovery and WINDOWS_SHELL_RESOLUTION_ENV, so those moved to a new
   buzz-model-catalog crate (no goose, no sqlite, same API). The desktop
   dependency is renamed in place, so no desktop source changes.

Also fixes a test that had been silently skipping: real_dev_mcp.rs located
buzz-dev-mcp by a hardcoded parent depth, so after the move it returned early
and reported 0.00s. It now searches upward and panics on a miss.

Workspace, sprig, and desktop all build. 41 tests green, fmt + clippy clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
The old version described a parallel `buzz-agent-core` crate and a `just
agent-core` recipe, neither of which exists any more -- the goose-backed loop
IS buzz-agent now.

There is nothing special to run: `just dev` and `just goose` already build and
use the swapped crate. If you see a difference, that is the bug.

Keeps the seven human-only checks (persona arrival, _Stop veto, streaming feel,
cancel leaving no stuck spinner, steering, model picker, hook-tool hygiene) and
the known gaps.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Audited every BUZZ_AGENT_* variable the desktop and buzz-acp inject against
what the swapped config.rs actually reads. Three gaps, two of them breaking.

1. `buzz-agent auth <provider>` was dropped in the rewrite. Goose owns provider
   auth for the agent loop, but nothing in goose does an *interactive*
   Databricks PKCE login -- and buzz-model-catalog/src/auth.rs:417 still tells
   users to run this exact command when the token cache is empty. Restored,
   now backed by buzz-model-catalog.

2. The desktop persists the provider as "databricks-v2" (agent_models.rs:757)
   but goose registers "databricks_v2" (goose-providers/src/databricks_v2.rs).
   An existing Databricks v2 agent would fail to start with "unknown provider".
   Added the alias; extracted the mapping into goose_provider_name() with tests
   including a pass-through case, since goose owns the registry and we must not
   gatekeep names we don't list.

3. BUZZ_AGENT_PREFER_MESH_FOR_AUTO is still injected (relay_mesh.rs:42) but is
   no longer honoured: it used to re-resolve the relay-mesh `auto` model against
   the /models catalog mid-run so a long-lived agent could join or leave MoA
   without restarting (old llm.rs:410-440). Goose resolves the model once at
   session start and has no equivalent hook. The agent still works, it just
   pins whatever `auto` resolved to at startup. Now warns loudly rather than
   ignoring it silently.

Verified: `buzz-agent auth` with no args and with a bogus provider both give
the same errors as before. 44 tests green, fmt + clippy clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Buzz owns the meaning of relay-mesh `auto`, and the swap had silently dropped
it. The desktop sets BUZZ_AGENT_PREFER_MESH_FOR_AUTO=1 on every relay-mesh agent
(relay_mesh.rs:41-44); the old loop honoured it per request (old llm.rs:406-470)
by polling the router's /models catalog and sending mesh-llm's virtual
Mixture-of-Agents model instead of `auto` whenever the mesh could support it.

I previously described this as "pins whatever auto resolved to at startup".
That was wrong: `auto` is a router-side id, so nothing resolves it -- the agent
just sent `auto` forever and MoA never engaged at all. For mesh-llm lab work
that is the entire feature missing, not a degraded version of it.

mesh::MeshAutoProvider wraps goose's provider and rewrites
ModelConfig.model_name per call. Provider requires only get_name + stream, so
wrapping is cheap -- and this is precisely the kind of interception that is only
possible with goose as a library; an out-of-process ACP agent has no seam for it.

Hysteresis is identical to the old implementation, deliberately: 5s catalog TTL,
two consecutive positive observations to enable, immediate disable plus a 30s
cooldown on a negative one, and an unreachable/malformed catalog preserves the
last confirmed route rather than treating a failed probe as evidence the mesh
vanished. A mid-request contraction (503 "MoA requires >=2 models" or
error.type=moa_failure) cools down and retries once on `auto`, so the turn still
completes. Other 5xx must NOT be treated as contractions -- that would mask real
outages behind a silent retry -- and there is a test pinning that.

4 end-to-end tests against a fake mesh-llm router assert what actually goes on
the wire: two-turn confirmation before MoA engages, single-model mesh never
routes to MoA, contraction produces a mesh->auto retry pair without failing the
turn, and the policy is inert (no extra /models polls) when the flag is absent.

That last test initially failed on an absolute catalog-hit count -- my
assertion was wrong, not the code: session/new polls /models for the desktop
model picker and goose does its own lazy capability lookup. Rewritten as a
differential across the TTL boundary, which isolates the policy's own poll.

48 tests green, fmt + clippy clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
I claimed the restored relay-mesh policy was identical to the old loop. Checked
it properly: constants, catalog parsing, hysteresis and the gate all match, but
contraction detection does not, and the difference is forced.

The old loop read the raw HTTP body and accepted two shapes: a 503 whose
error.message is the MoA-unavailable string, or any 5xx whose error.type is
"moa_failure". A provider-level wrapper only sees what goose leaves behind, and
extract_message (goose-providers/src/http_status.rs:186-197) reduces the payload
to error.message when that field exists.

So "moa_failure" *alongside* a message is invisible to us and fails the turn
instead of retrying on auto. Verified by probe, not assumed. The message shape
-- which is what mesh-llm's under-provisioned path actually sends -- still works,
as does moa_failure with no message.

Documented on is_mesh_contraction and pinned with a test that fails loudly if
goose ever stops stripping the body, so the caveat cannot silently rot.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Yesterday I documented a "known blind spot" and left it. Checking mesh-llm's
actual source shows that was the wrong call: the gap covers the COMMON failure,
not a rare one.

mesh-llm has two failure paths:

  503, gateway level  — mesh too small to start MoA at all.
                        Plain message, no JSON.
                        (moa_gateway/mod.rs:56)
  502, MoA level      — workers or reducers died mid-turn.
                        Body carries error.message AND error.type=moa_failure.
                        (mesh-mixture-of-agents/src/lib.rs:1168)

Only the 503 was handled. The 502 is the one that actually fires in a running
lab — a worker dropping out mid-turn — and because goose reduces a payload to
error.message when that field exists (http_status.rs:186-197), the moa_failure
type never reached us. Those turns failed outright instead of retrying on auto.

Fixed by matching the messages themselves: MOA_FAILURE_MESSAGES lists every
error_response call site in mesh-llm. The JSON-shaped check stays as a fallback
for moa_failure with no message field.

Message matching is brittle if mesh-llm rewords these, but the alternative is
an HTTP-level seam goose does not expose, and silently losing the fallback is
worse than a string that needs updating.

49 tests green, fmt + clippy clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…-core

* origin/main: (48 commits)
  fix(buzz-acp): accept id-keyed config options when resolving model switch (#2795)
  fix(desktop): probe legacy Goose install dir on Windows (#3248)
  refactor(desktop): extract install command execution into install_exec (#3251)
  Polish composer activity layout and transitions (#3151)
  feat(invites): add use-limited invite links (#3141)
  fix(node): bump Buzz-supplied Node runtimes past OpenClaw's >=24.15.0 floor (#3218)
  fix(desktop): preserve thread anchor through layout reflow (#3212)
  feat(search): parse from:/in:/after:/before: and pass them in the filter (#2871)
  fix(desktop): fetch join policies through native networking (#2862)
  fix(desktop): republish agent identity records when a persona rename propagates (#2607)
  fix(desktop): keep project Inbox previews compact (#3193)
  Inbox refactor (#2045)
  Fix composer selection formatting and drop overlay (#3172)
  Refine pending message status (#3153)
  feat(admin): show reported message content in report detail (#3149)
  fix(desktop): recover full local storage on startup (#3182)
  Replace mobile reconnect banners with skeleton shimmer (#3143)
  fix(desktop): keep collapsed table separators out of spoilers (#3169)
  chore(deps): update plugin org.jetbrains.kotlin.android to v2.2.21 (#3058)
  resolve findings (#3150)
  ...

Signed-off-by: Michael Neale <michael.neale@gmail.com>

# Conflicts:
#	crates/buzz-agent/src/mcp.rs
An adversarial review of the swap found several real defects, most of them
comments asserting parity that did not survive checking. Fixed:

B3 serve() dropped in-flight work on stdin EOF. Dropping a CancellationToken
   does not cancel it, so goose never sent notifications/cancelled to its MCP
   children and they outlived us as orphans -- exactly the failure run_turn
   goes to lengths to avoid on session/cancel. The detached writer task was
   also never awaited, so frames still queued (including a response from a turn
   that finished on the same tick) were discarded. main did both; restored.

B4 max_sessions was TOCTOU-racy. session/new is dispatched on its own task and
   build_agent (MCP spawn + provider round-trip) sits between the check and the
   insert, so N concurrent calls all passed. main re-checked under the insert
   guard; that guard had been dropped. Restored.

B5 usage_update reported an empty model whenever the model came from
   GOOSE_MODEL rather than BUZZ_AGENT_MODEL -- a supported path everywhere else
   (build_agent and session/new both fall back to it). Blanked kind-44200
   attribution silently. Now uses the same resolution chain.

B6 BUZZ_AGENT_LLM_TIMEOUT_SECS was parsed, documented, and never read. Deleted
   rather than left as a knob that does nothing.

R2 A whitespace-only steer returned success instead of INVALID_PARAMS. buzz-acp
   maps success to SteerAck::Ok and treats the message as delivered, so it was
   swallowed and the cancel+merge fallback suppressed. Now rejected up front,
   before touching the session map, as main did.

R6 Restored #![forbid(unsafe_code)], lost in the rewrite.

B1/B2 are documentation corrections, and they matter more than the code fixes:
the module table claimed builtin.rs was replaced by goose's skills extension
and hints.rs by goose's hint loader. Neither holds. Agent::with_config loads
zero extensions and build_agent only adds the harness's declared mcpServers, so
the skills extension is never loaded and load_skill/SKILL.md discovery are
simply gone. Goose's hint loader keys off .goosehints while the old code walked
for AGENTS.md -- every repo here ships the latter and none the former, so no
hints load at all. Both now documented as losses instead of substitutions.

53 tests green, fmt + clippy -D warnings clean workspace-wide.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
… loop

The swap documented both as losses (B1/B2 in fa8166e). Restore them by
keeping the old buzz-agent modules and wiring them into goose, rather than
using goose's own equivalents, which don't fit:

* goose's hint loader keys off GOOSE_HINTS_FILENAME (.goosehints); our repos
  ship AGENTS.md. hints.rs (directory-chain walk + ~/AGENTS.md + skill
  discovery under .agents/skills, .goose/skills, .claude/skills) is kept and
  its output injected via extend_system_prompt("buzz_hints") at session
  build — system_prompt_extras survive override_system_prompt, so this works
  with the persona path too.

* goose's skills platform extension is never loaded (Agent::with_config
  loads zero extensions; build_agent only adds the harness's mcpServers).
  builtin.rs / load_skill is kept and registered as a goose *frontend*
  extension: goose advertises the tool, and we answer the calls in-process.

The frontend-tool wiring had a deadlock in the first cut: it listened for
MessageContent::ToolRequest, but goose strips frontend calls out of the
normal ToolRequest flow (reply_parts.rs categorize_tools) and yields a
dedicated FrontendToolRequest variant instead, then BLOCKS the reply stream
on tool_result_rx.recv() until handle_tool_result is called. The handler
never matched, so the first load_skill call hung the turn forever — this is
what the hung `cargo test --test skills` processes on this machine were.
Now:

* serve_frontend_tool matches FrontendToolRequest, answers every yielded
  request exactly once (unknown tool name gets an error result rather than
  silence — goose is already blocked on the id), and skips only the Err
  parse case, where goose does not block (tool_execution.rs:181 yields
  inside the Ok arm only).

* emit_content announces FrontendToolRequest to the desktop as a tool_call
  update; its result comes back as a plain ToolResponse, and an update for
  a never-announced id would break the announce→terminal pairing that keeps
  the UI spinner honest.

The skills integration test drives the full path over stdio against a fake
SSE provider: AGENTS.md content and the skill name/description index must
reach the system prompt, the skill BODY must not (that is the point of
load_skill), load_skill must be advertised, and the turn must complete —
a broken frontend-tool path fails by hanging, so the turn completing IS the
assertion.

53 buzz-agent tests green; fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
The Security job's cargo-deny licenses gate rejected MIT-0 (MIT No
Attribution — OSI approved, strictly more permissive than MIT), newly
pulled in via goose → jsonschema → referencing → fluent-uri →
borrow-or-share.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…t a frontend tool

Adopts the shape Maple uses for its in-process tools (MapleDeveloperClient,
SkillsClient): an `McpClientTrait` impl registered via
`extension_manager.add_client` with an `ExtensionConfig::Platform`, rather than
`ExtensionConfig::Frontend`.

Goose advertises frontend tools but refuses to dispatch them. It yields a
`FrontendToolRequest` and blocks the reply stream until the embedder calls
`handle_tool_result` -- strictly sequential, no timeout, and a single result
channel with no request-id correlation, so one missed or duplicated result
wedges the session for good and the cancel token will not free it. That is a
failure mode with no upside here. A platform client goes through goose's
ordinary tool path and gets concurrency, per-request timeouts and
`notifications/cancelled` for free.

Net effect is less code: `serve_frontend_tool` is gone, and with it the
`skills` parameter threaded through run_turn -> drive_stream -> handle_event
and the `Session.skills` field. The BuiltinClient owns the skill list.

Also fixes a real mismatch the swap exposed. Goose namespaces platform tools as
`{extension}__{tool}` (`extension_manager.rs:1415`), so the model sees
`buzz__load_skill`, but the skills section of the system prompt told it to call
`load_skill`. The prompt now derives the name from the same constants the
registration uses, so the two cannot drift.

89 tests green, fmt + clippy -D warnings clean.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Nine conflicts. The deletions resolve trivially -- goose owns those files now,
so mcp.rs, llm.rs, handoff.rs and tests/fake_llm.rs stay deleted.

The four content conflicts (agent.rs, config.rs, lib.rs, types.rs) all resolve
to this branch's rewrite, but two of main's changes are wire-facing and buzz-acp
now depends on them, so they are ported onto the goose loop rather than
discarded:

* #3463 `accumulatedCachedInputTokens` -- buzz-acp/src/usage.rs reads it for
  pricing. Sourced from goose's `cache_read_input_tokens` +
  `cache_write_input_tokens`, which goose documents as subsets of
  `input_tokens` (`token_usage.rs:72-78`), so it stays a subset here too.
* #3593 `accumulatedTotalTokens` -- emitted only when exactly known. One turn
  without a provider total poisons the session cumulative to `None` and the
  field is omitted, because buzz-acp must not read a missing total as zero.

Cargo.lock was regenerated rather than resolved by hand: main moved mesh-llm to
v0.74.0, which requires rmcp ^1.8, and the stale lock still pinned 1.7.0.

89 tests green, fmt + clippy -D warnings clean workspace-wide; workspace,
sprig and desktop/src-tauri all build.

Signed-off-by: Michael Neale <michael.neale@gmail.com>
…rmcp 3.x breaks

Moves the goose git pin from 305849b to bf332b9, which is past ca52cce
(#9574, the unrolled agent loop). Consequences of the bump:

- goose now uses rmcp 3.x; buzz-agent's own rmcp dep moves 1 -> 3 so the
  two do not resolve to different crate versions of the same types.
- rmcp 3 renames Content -> ContentBlock and adds fields to
  ListToolsResult; builtin_client.rs updated to match.
- buzz-model-catalog needs an explicit dirs dep after the merge.
- TurnTotalState / PricingIdentity, added on main while this branch was
  away, ported back into types.rs for wire.rs's usage_update_payload.

cargo check -p buzz-agent passes.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Inverts the previous design. buzz-agent no longer calls
goose::agents::Agent::reply -- it drives its own round loop and calls
goose for the four heavy components:

  model call        Provider::stream (via Agent::provider)
  tool surface      Agent::list_tools
  tool execution    Agent::dispatch_tool_call
  system prompt     PromptManager (our own instance)
  compaction        context_mgmt::{check_if_compaction_needed, compact_messages}

Under Agent::reply every buzz-specific behaviour had to be smuggled in
around goose's turn policy: the _Stop veto needed an outer loop purely to
re-enter reply(), and [Reflect] had to be delivered as a *steer* because
the tool result itself was out of reach. Owning the loop removes the
smuggling -- the veto is a branch, and [Reflect] goes back on the tool
result where buzz-agent originally put it and where the model reads it in
context.

New modules:
  loop_drive.rs  the round loop: inference, tools, compaction, _Stop veto,
                 steer drain, cancellation, max-rounds bound
  tools.rs       parallel dispatch, announce->terminal wire invariant,
                 [Reflect] on failure
  prompt.rs      our PromptManager (goose's is pub(super) to Agent::reply)
  steer.rs       our steer queue (goose's drain is pub(crate) to reply)

agent.rs shrinks to what goose knows nothing about: ACP session/update
emission and the keepalive ticker. Tool lifecycle now emits from tools.rs,
which is the only place that knows when a call starts and ends -- emitting
from streamed content as well would double-announce every tool.

Behaviour preserved and covered by the existing suite: Fizz persona,
_Stop veto (cap 3), [Reflect] (cap 8), _PostCompact re-injection,
steering without cancellation, cancel leaving no unresolved tool call,
usage accounting, and the model catalog.

81 unit + 21 integration tests pass; clippy clean (the one hints.rs
warning predates this branch).

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
…tdio

Closes the biggest coverage gap on this branch: every automated test talks
to a fake SSE server, so nothing proved the loop works against a real
model. scripts/handtest.py starts the actual binary, speaks real ACP to
it, and asserts on the five behaviours that are easy to break and hard to
notice:

  basic      persona + AGENTS.md hints reach the model; catalog populated
  tools      a real MCP tool is dispatched and its output comes back
  stop-veto  _Stop blocks end-of-turn, and the cap still releases it
  cancel     cancel mid-tool leaves no tool call spinning
  steer      a mid-turn steer is absorbed without restarting the turn

All 18 checks pass against Anthropic claude-sonnet-4-6. Each mode gets a
fresh process so one mode's conversation cannot pollute the next.

HANDTEST.md updated: describes the script, and its 'never run against a
real provider' known gap is replaced with what is now actually covered.
Also corrects the opening paragraph, which still said goose owns the loop.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
buzz-acp inherits its stderr to the agent subprocess (acp.rs:463), so the
desktop's RUST_LOG filter for the harness also decides what the agent can
write to disk. It named only buzz_acp, so every buzz-agent diagnostic was
filtered out before reaching the per-agent log.

That is invisible until something goes wrong: an agent misbehaving in the
desktop left no trace of compaction, the _Stop veto, max-rounds, or
provider errors -- exactly the lines you need to tell whether a turn did
what it should. Found while trying to confirm from the logs alone that a
desktop agent was running the new loop; the logs could not answer it.

Adds buzz_agent=info to the default, appends only the missing directive
when an operator filter is present (so RUST_LOG=buzz_agent=debug is not
downgraded to info), and takes a fully-specified filter verbatim.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Adopts main's tool-call permission gate (#5712) onto the goose loop. The
conflicts were semantic, not textual: #5712 landed +2494/-299 inside
`crates/buzz-agent`, the crate this branch rewrote, so five of main's files
(`llm.rs`, `mcp.rs`, `fake_llm.rs`, `regressions.rs`, and main's `agent.rs`
loop) no longer exist here and their share of the feature had to be
re-expressed against goose.

Taken from main unchanged: `permission.rs` (the broker), `wire.rs`'s
`session/request_permission` shapes, `Inbound::Response`, `send_checked`, and
`write_frames`.

Re-expressed:

* The gate moved from main's `RunCtx::execute_parallel` to `tools::run_one`,
  which is where this branch dispatches model-issued calls. It runs after the
  `tool_call` announcement and before `Agent::dispatch_tool_call`, so a denied
  call still reaches a terminal wire state.
* The broker takes goose's `CancellationToken` instead of a
  `watch::Receiver<bool>`, and an `AskSubject` instead of buzz's deleted
  `ToolCall` type.
* Argument-shape validation is dropped: it guarded main's `mcp.rs`, and goose's
  `CallToolRequestParams::arguments` is already `Option<Map>`, so a non-object
  cannot be represented.
* `max_pending_permissions` / `permission_timeout` follow this crate's
  `env_parse`-with-default style rather than main's validated `parse_env`.

Two behaviour notes worth review:

* `load_skill` is now **gated**, where main exempted it as an in-process
  built-in. Under goose it is a real tool on the skills platform extension,
  dispatched through the same path as any MCP tool. Exempting it would need an
  allowlist keyed on tool *name* — which an untrusted MCP server controls, and
  could register its own `load_skill` to inherit. Test renamed accordingly.
* Connection teardown already cancels every session (`serve`'s shutdown), so
  main's `cancel_all_sessions` plus writer-death select arm had no separate work
  to do here; the broker's wire-closed path covers the rest.

Test harness changes:

* `tests/common/mod.rs`'s fake LLM now answers SSE. goose's openai-compatible
  provider requests `stream: true` and parses `chat.completion.chunk`; against
  main's plain `chat.completion` body every turn ended `end_turn` with no tool
  call and no ask, which reads as a broken gate rather than a broken fixture.
  Its `/models` lookup is also answered without popping the canned queue.
* `tests/approve/mod.rs` auto-approves for the nine suites whose subject is not
  the boundary, selecting by option `kind` rather than a hardcoded `optionId`.
* `cancel.rs` now answers the ask and waits for `FAKE_MCP_CALL_RECEIVED` before
  cancelling. Without that the cancel resolved the *permission wait*, the tool
  was never dispatched, and `cancel_propagates_notifications_cancelled_to_mcp`
  failed — a test named "cancel mid tool call" was exercising "cancel before
  tool call".

Verified at this tree: `buzz-agent` full suite 111 unit + 36 integration
(including all 9 boundary tests) pass, `clippy --all-targets -D warnings`
clean, `cargo fmt --check` clean, `cargo build --workspace` clean, and both
`Cargo.lock` files resolve `--locked` (8 goose deps in root, 0 in desktop).

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
goose's skills platform factory calls plain `SkillsClient::new`
(`platform_extensions/mod.rs:219-221`), which leaves goose's two compiled-in
skills switched on: `goose-doc-guide` and `web-search`
(`goose/src/skills/builtins/`). Neither is a Buzz skill. `web-search` tells the
model to shell out to `uvx ddgs` / Tavily / SearXNG — a capability Buzz does not
provide and never advertised on main, where `buzz-agent` scanned four
filesystem directories and had no builtins at all (`hints.rs:8`, `:204-217`).
So the goose swap widened every Buzz agent's advertised surface as a side
effect, which is not what "same agent, goose underneath" means.

`with_builtin_skills(false)` is only reachable off the constructor, so buzz
builds the client itself and registers it with `ExtensionManager::add_client`
instead of by name. The prompt index is filtered to match: a skill listed in
the index but absent from the client is a dead `load_skill` reference.

The `add_client` route keeps the bare tool name. `is_unprefixed_extension`
(`extension_manager.rs:392-400`) keys off the `ExtensionConfig`, not the
registration route, and this passes the same
`ExtensionConfig::Platform { name: "skills" }` the factory does, so the table's
`unprefixed_tools: true` still applies. The context is given the session
explicitly because `SkillsClient::new` reads `session.working_dir` for
discovery and falls back to the process cwd without it.

Measured against the real `~/.buzz` nest with the built binary and a local
fake SSE provider (no model call): index 13 skills -> 11, tool list still
`["load_skill"]`, `load_skill("buzz-cli")` still returns its body,
`load_skill("web-search")` now returns "not found". System prompt 12,632 ->
12,079 bytes.

`tests/skills.rs` gains two assertions it was missing. The existing test only
checked `stopReason == end_turn` and that a second round happened — a
`load_skill` answering "Skill not found." passes both, so the suite could not
distinguish a working skills path from a broken one. It now asserts the skill
body reached the model as a tool result. The new test pins the builtins from
both sides (absent from the index AND unresolvable through the tool) and was
verified to fail on the unpatched `lib.rs`.

buzz-agent: 111 unit + 36 integration green, `clippy --all-targets -D warnings`
clean, `fmt --check` clean.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
@micspiral

Copy link
Copy Markdown
Collaborator

Test-parity review: three specific gaps in loop bounding

Read at origin/main e8172b5b vs this branch 0a4c78e1e. Everything below is a git grep result on those two trees, not an inference.

Framing first, because the headline numbers are misleading: 612 tests → 198 looks alarming but most of it is correct. llm.rs (186 tests) tested a request transport that no longer exists; ~38 of config.rs's 100 were anthropic_thinking_config_* per-model permutations for a request buzz no longer builds; the 13 handoff tests covered a summariser goose now owns. permission.rs (21) and wire.rs (18) carried over intact, and the Databricks OAuth tests moved to buzz-model-catalog (50). Those are not gaps and I am not asking for them back.

Three things I do think are gaps, all in loop bounding — the part that decides when a turn stops.

1. BUZZ_AGENT_MAX_ROUNDS unset changed meaning, and the README still documents the old one

  • main: max_rounds: parse_env("BUZZ_AGENT_MAX_ROUNDS", 0), and agent.rs:362 guards if self.cfg.max_rounds > 0 && round >= … — unset means unbounded.
  • branch: config.rs:223 env_parse::<u32>(…).filter(|n| *n > 0)None, and loop_drive.rs:172 ctx.max_rounds.unwrap_or(DEFAULT_MAX_ROUNDS) where DEFAULT_MAX_ROUNDS = 1000 (loop_drive.rs:88) — unset means 1000.

The change is deliberate and the reasoning in the doc comment is good ("owning the loop means owning the bound"). Two problems:

  • README.md:334 still says | Loop rounds | 0 (unlimited) | BUZZ_AGENT_MAX_ROUNDS |, and README.md:155 still says 0 = unlimited. Both now describe main's behaviour.
  • DEFAULT_MAX_ROUNDS has two references in the whole branch — its definition and its one use. No test pins it. Main had finite_round_cap_still_binds_without_a_context_overflow and max_tokens_recovery_respects_finite_round_cap.

To close: fix the two README rows to say 1000, and add one test asserting an unset max_rounds stops at 1000 with StopReason::MaxTurnRequests. ops.rs's at_budget_ends_the_turn_with_max_turn_requests is the pattern; it just needs the default-path case.

2. The documented ordering between the tool-call cap and the reply guard is untested

Correcting my own earlier count: reply-guard coverage is 10 → 5, not 10 → 1. ops.rs has the_reply_guard_reminds_when_nothing_was_published, a_publish_attempt_disarms_the_guard, an_unrelated_tool_call_does_not_disarm_the_guard, a_hallucinated_shell_does_not_disarm_the_guard, the_guard_stops_after_its_budget. That is the core of the guard and it is well covered.

Four of main's cases have no counterpart:

main test branch
reply_guard_ignores_calls_lost_to_the_turn_cap none
reply_guard_bounded_by_stop_rejection_budget none
reply_guard_off_when_stop_budget_is_zero none
reply_guard_combines_with_stop_hook_objection none

The first is the one worth adding, because README.md:217-219 states the property as a guarantee: "Detection is checked after the per-turn tool-call cap (MAX_TOOL_CALLS_PER_TURN) is applied: a publish-shaped call that was discarded never ran." loop_drive.rs:408-415 truncates, BuzzReplyGuardOperation inspects the conversation afterwards — so the behaviour looks right, but nothing tests it, and it is now an emergent property of two separate components rather than one function's control flow. The other three are guard × stop-hook budget interactions; both mechanisms exist on this branch and both have tests, but nothing tests them together.

To close: one test with >64 publish-shaped calls in a round asserting the guard still nags, plus one guard-under-exhausted-stop-budget test.

3. Six documented caps have no implementation on this branch

README.md:325-332 lists these. Grepping the entire branch for each name returns exactly one hit — the README row itself:

cap main implementation branch
MAX_MCP_SERVERS (16) mcp.rs:26,207-209 rejects README only
MAX_TOOLS_PER_SESSION (128) mcp.rs:22,262-264 rejects README only
MAX_DESCRIPTION_BYTES (1 KiB) mcp.rs:23,286 clamps README only
MAX_SCHEMA_BYTES (4 KiB) mcp.rs:24,880-884 replaces with {} README only
MAX_TOOL_RESULT_BYTES (8 MiB) config.rs:384, agent.rs:891 README only
MAX_LLM_RESPONSE_BYTES (16 MiB) llm.rs:23,1966 README only

MAX_LLM_RESPONSE_BYTES is fine to drop — that is transport, goose's now. The other five are MCP-facing limits, and MCP servers are the untrusted input here: MAX_SCHEMA_BYTES and MAX_DESCRIPTION_BYTES existed so one server's oversized tool definitions could not crowd out the prompt.

MAX_TOOL_RESULT_BYTES is the one I would check first. BUZZ_AGENT_MAX_TOOL_RESULT_TEXT_BYTES is projected onto goose's GOOSE_MAX_TOOL_RESPONSE_SIZE (config.rs:347-348), so tool result text is bounded — but main's 8 MiB was the total including images, and I found nothing on this branch enforcing a total. Mitigating: lib.rs:549 declares promptCapabilities.image: false, so images cannot arrive via the prompt; the unbounded path would be an MCP tool returning image content.

To close: for each of the five, either cite the goose mechanism that enforces it (and change the README row to name that mechanism) or delete the row. A documented limit with no enforcement is worse than no limit, because a reviewer reads the table and concludes it is handled.

Also worth listing explicitly in the PR body

permission_boundary.rs inverts a main assertion: test_load_skill_emits_no_permission_request (main, asserts zero requests) → test_load_skill_is_gated_like_any_other_tool (branch, asserts one). The doc comment argues it well — exempting load_skill would need a name-based allowlist and tool names are attacker-controlled — and I agree with the call. But it is a user-visible behaviour change and the PR body does not mention it. Any test whose assertion flipped should be listed there, because both suites are green while asserting opposite things, so review cannot see it from CI.

One process note

scripts/run-tests.sh:121-122 runs cargo test -p buzz-agent --lib — unit tests only, on both main and this branch. The 10 integration files in tests/ (stdio_turn.rs, provider_faults.rs, steer.rs, cancel.rs, stop_hook.rs, skills.rs, real_dev_mcp.rs, permission_boundary.rs, memory.rs, reflect.rs) are the best evidence in this PR — they drive the real binary over stdio against a live socket provider — and CI is not running them. Green CI is not evidence they pass. Adding them to that script would make this PR's strongest evidence load-bearing.


I have not run either suite; disk and toolchain constraints on this machine meant I read both trees rather than executing them. Every claim above is a grep on e8172b5b / 0a4c78e1e with the file and line cited, so each is cheap to falsify — please do.

Jimmy and others added 3 commits August 27, 2026 10:19
Depend on goose-agent at the same pinned Goose revision and import its state
machine, operation traits, effects, and turn-counting helpers directly. This
removes Buzz's copied messages_since_kickoff and assistant_turn_count helpers
without changing loop policy or the broader Goose dependency graph.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
…e-agent

Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Bring the draft branch to current Buzz main, update Goose/GDK to 1.48, preserve the extracted model catalog while porting Databricks filtering, and ensure capped tool calls do not remain in reply-guard history. Run all buzz-agent targets in the unit gate.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
@github-actions

github-actions Bot commented Aug 31, 2026

Copy link
Copy Markdown

🔐 Codex Security Review

Status: review required for the current range.

The current range is b1f6b7ef770dddbb7f33c9f5861c379a47bca1d6...7a81512ee18fd7d3259a78b8bd958ce6143ee799.
A new review must complete for this exact range. When manual authorization
is required, a Block organization member must comment exactly
@buzz-security-review 7a81512ee18fd7d3259a78b8bd958ce6143ee799 to authorize a new review.
Any previous review applies only to its recorded range.

Jimmy and others added 6 commits August 31, 2026 13:46
Bring the GDK preview and draft branch onto current Buzz main before packaging.

Co-authored-by: Michael Neale <michael.neale@gmail.com>

Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Merge Buzz main at 4a9de1a, preserving the extracted model catalog and full buzz-agent test coverage. Carry named-demo OAuth cache isolation through the extracted catalog, and update Goose/GDK to 0a9749b1c for the latest security and conversation fixes.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Store the provider, model config, and exact ACP model id as one atomic
session snapshot. Each turn captures that snapshot before inference so a
concurrent or failed model switch cannot relabel usage for another model.

Add stdio regressions for in-flight and repeated model switches, including
request-side model capture and usage-update attribution.

Signed-off-by: Thinker <75d8a808fa21bb8d1812e080cf471db601c6e8e6a62dcd516d37531e83a7bb77@meshllm.communities.buzz.xyz>
Replace the monolithic Agent extension manager with a bounded direct RMCP registry while preserving lifecycle hooks, permission gating, skill loading, process-tree cleanup, and tool-result limits. Keep turn state in Buzz memory instead of a Goose sqlite session.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Read the shell-resolution allowlist from buzz-model-catalog, where the shared desktop/agent contract moved. This restores the Windows-only build after the direct RMCP registry change.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Enforce separate normal-tool and lifecycle-hook deadlines at Buzz's direct RMCP boundary. Cancel and poison timed-out servers so existing lazy restart semantics recover them, and cover the behavior through the stdio integration harness.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
jamadeo and others added 5 commits September 2, 2026 10:29
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Use a conservative visible-history estimate when providers omit usage, and compact then retry when a provider reports context overflow. Keep the granular GDK compaction seam and cover both fallback paths.

Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
…-core

Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>

# Conflicts:
#	Cargo.lock
#	Justfile
#	crates/buzz-agent/README.md
#	crates/buzz-agent/src/config.rs
#	crates/buzz-agent/tests/fake_llm.rs
#	crates/buzz-model-catalog/src/auth.rs
#	desktop/src-tauri/Cargo.lock
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
jamadeo and others added 6 commits September 2, 2026 12:08
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Co-authored-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Co-authored-by: Michael Neale <michael.neale@gmail.com>

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>
Co-authored-by: Michael Neale <michael.neale@gmail.com>

Signed-off-by: Michael Neale <michael.neale@gmail.com>
Signed-off-by: Jimmy <1fe240cd1a8cf775f6f3060f115e5a303181f3abf28ad4cb0c2515f4a02b36a8@meshllm.communities.buzz.xyz>

@salman1993 salman1993 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed at d2cc2e1779161b4d3a6e920952ea81a5ef2a8ebb. Two reproducible agent-loop regressions need fixing before approval.

Validation: built buzz-dev-mcp, then ran both whole package suites (cargo test -p buzz-agent -p buzz-model-catalog): 277 passed, 1 ignored across targets. Additional probes used the real buzz-agent binary over ACP stdio with an isolated local fake HTTP provider and temp HOME/XDG directories. No live-relay or real-provider pass is claimed.

Review by Leo (AI agent).

Comment thread crates/buzz-agent/src/loop_drive.rs Outdated
conversation.messages(),
&tools,
)
.await

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Make stream construction cancellable, not just stream consumption.

Provider::stream() does not immediately return a lazy stream: the pinned Goose OpenAI implementation awaits the HTTP POST (including retries/response headers) before returning. This await is outside both the cancellation select and keepalive ticker below. A queued/stalled gateway therefore prevents cancellation until headers arrive or the provider timeout expires, and the harness sees no keepalive during that wait. Main wrapped the entire LLM call in the cancellation select.

Reproduced with the actual ACP binary: accept the completion POST but withhold HTTP response headers; send session/cancel; no prompt response within 3 seconds. Releasing headers immediately produces stopReason: cancelled. Please cover the stream-construction future with cancellation/keepalive and add a regression that stalls before headers, not only between SSE chunks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reply from MicBlock's AI agent (Galadriel).

Fixed in 7a81512. Provider::stream()'s construction await is now pinned and covered by the same tokio::select! as chunk consumption — session/cancel interrupts it immediately and keepalives keep ticking during the pre-headers wait (loop_drive.rs::infer).

Regression test cancel_during_header_stall_returns_promptly (tests/cancel.rs) does exactly your repro: a provider that accepts the completion POST but parks the socket without response headers, BUZZ_AGENT_LLM_TIMEOUT_SECS=300 so a prompt response can only come from cancellation, then asserts stopReason: cancelled in <5s. Passes; full cargo test -p buzz-agent green.

else {
break;
};
if !apply_effects(&mut state, effects) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 — Bound proactive compaction before rerunning the start machine.

Every successful compaction returns a replacement, so apply_effects reports progress and clears total_tokens. But compacted_conversation preserves the latest user text verbatim. If that text alone exceeds the byte-estimated threshold, the next start-machine step compacts it again indefinitely. Neither the outer max-rounds gate nor the reactive context-recovery counter bounds this inner loop.

Reproduced with GOOSE_CONTEXT_LIMIT=1000, BUZZ_AGENT_MAX_ROUNDS=1, a 2000-byte user prompt, and a provider returning a short valid summary: 8 consecutive summary requests, zero normal inference requests, and no prompt completion before I explicitly cancelled. A larger prompt exceeding the threshold of a normal-sized context produces the same condition. Main capped proactive handoff attempts and then used truncation. Please enforce a proactive compaction budget/no-progress policy so repeated successful summaries cannot run up unbounded requests without advancing the task.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reply from MicBlock's AI agent (Galadriel).

Fixed in 7a81512. Proactive compaction now has a no-progress budget of one per inference: BuzzCompactionOperation tracks compacted_since_inference (shared across the loop's forced path and the start-machine gate); a second proactive trip before any inference is refused and the request goes to the provider, whose acceptance or context-400 is authoritative — genuine overflow then takes the existing bounded reactive path (3 per turn). Forced/reactive compaction also consumes the budget, and each successful inference re-arms it.

Regression test oversized_prompt_does_not_spin_proactive_compaction (tests/provider_faults.rs) uses your exact repro — GOOSE_CONTEXT_LIMIT=1000, BUZZ_AGENT_MAX_ROUNDS=1, 2000-byte prompt, short valid summaries — and asserts the turn completes with ≤4 provider requests (pre-fix: 8+ summaries, zero inference, no completion). Passes.

Comment thread crates/buzz-agent/README.md Outdated
| `BUZZ_AGENT_LLM_TIMEOUT_SECS` | `240` | Max seconds with no response bytes before abandoning an LLM call (per-read inactivity, not wall-clock). |
| `BUZZ_AGENT_TOOL_TIMEOUT_SECS` | `1260` | Per-tool call timeout in seconds |
| `BUZZ_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) |
| `BUZZ_AGENT_MAX_HANDOFFS` | | **No longer read.** goose's compaction replaced the handoff mechanism it bounded. |

@salman1993 salman1993 Sep 4, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Flagging several high-risk environment-variable changes that appear to remove safety controls without equivalent replacements:

  • BUZZ_AGENT_MAX_HANDOFFS: removed, but it previously bounded compaction attempts and fell back to truncation. Without an equivalent cap/progress check, compaction can loop indefinitely.
  • BUZZ_AGENT_MAX_HISTORY_BYTES: removed, while compaction only hides old messages from the model rather than removing them from memory. Long-lived sessions may grow without bound.
  • BUZZ_AGENT_MAX_TOKEN_RECOVERIES: removed, and it is unclear whether truncated model responses still get safely continued with a bounded recovery budget.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🤖 Reply from MicBlock's AI agent (Galadriel).

All three addressed in 7a81512, taking each variable on its merits:

  • BUZZ_AGENT_MAX_HANDOFFS — stays removed, but the unbounded-compaction risk it guarded is now closed structurally: at most one proactive compaction per inference (no-progress guard in ops.rs) plus the existing cap of 3 reactive compactions per turn on provider context-overflow. See the P1 thread above for the regression test. README row now documents the replacement bound instead of just "no longer read".

  • BUZZ_AGENT_MAX_HISTORY_BYTESrestored (default 16 MiB, values <1 MiB ignored). Your diagnosis was right: goose compaction only marks messages agent-invisible, it deletes nothing, so carried-forward history grew without bound. turn_state::evict_hidden_history now evicts the oldest agent-invisible messages once the stored history exceeds the budget; model-visible messages are never evicted (their footprint is what token compaction bounds). Unit-tested.

  • BUZZ_AGENT_MAX_TOKEN_RECOVERIESrestored (default 3, 0 disables), because goose does not continue truncated responses itself: it marks them (output_token_limit_reached) and leaves the policy to the embedder. Behaviour: tool calls from a truncated response are discarded and never executed (a later call may have been cut off), surviving text is preserved, an agent-only continue nudge is injected, and exhausting the budget surfaces as stopReason: max_tokens. Two integration tests pin recovery-then-completion and the bounded persistent-truncation case.

Full cargo test -p buzz-agent green (17 suites, 0 failures) at 7a81512.

…tion with cancel

Addresses salman1993's review at d2cc2e1 (two P1s) and the follow-up
environment-variable round:

- Cancellable stream construction (P1). goose's Provider::stream() awaits
  the HTTP POST — headers included — before returning, so a queued/wedged
  gateway stalled the turn outside the cancellation select and keepalive
  ticker. The construction future is now covered by the same select as
  chunk consumption. Regression test parks the completion socket
  pre-headers and asserts session/cancel returns `cancelled` promptly.

- Proactive compaction budget (P1). A user prompt that alone exceeds the
  byte-estimated threshold re-tripped the gate after every successful
  summary (8 summary requests, zero inference). Budget is now one
  proactive compaction per inference; forced (reactive) compaction also
  consumes it, and a successful inference re-arms it. Genuine overflow
  still takes the bounded reactive path (3 per turn). Regression test
  reproduces the oversized-prompt spin.

- BUZZ_AGENT_MAX_TOKEN_RECOVERIES restored (default 3). goose marks
  output-token-truncated responses but leaves policy to the embedder.
  Truncated tool calls are discarded (never executed), surviving text is
  preserved, and a continue nudge is injected within the bounded budget;
  exhaustion surfaces as `max_tokens`. The truncation flag is also
  propagated through chunk accumulation so the marker message's metadata
  is not lost.

- BUZZ_AGENT_MAX_HISTORY_BYTES restored (default 16 MiB, 1 MiB floor).
  Compaction hides messages from the model rather than deleting them, so
  carried-forward session history grew without bound. The oldest
  agent-invisible messages are now evicted once the stored history
  exceeds the budget; model-visible messages are never evicted.

- BUZZ_AGENT_MAX_HANDOFFS stays removed: the compaction it bounded is now
  bounded structurally (one proactive per inference + 3 reactive per
  turn); the README row documents the replacement.

Co-authored-by: Galadriel <galadriel@buzz.agents>
Signed-off-by: Michael Neale <michael.neale@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants